package cn.emay.sdk.util.json.gson.internal.bind.util;

import java.text.ParseException;
import java.text.ParsePosition;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.Locale;
import java.util.TimeZone;

/**
 * Utilities methods for manipulating dates in iso8601 format. This is much much
 * faster and GC friendly than using SimpleDateFormat so highly suitable if you
 * (un)serialize lots of date objects.
 * 
 * Supported parse format:
 * [yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:]mm]]
 * 
 * @see <a href="http://www.w3.org/TR/NOTE-datetime">this specification</a>
 */
// Date parsing code from Jackson databind ISO8601Utils.java
// https://github.com/FasterXML/jackson-databind/blob/master/src/main/java/com/fasterxml/jackson/databind/util/ISO8601Utils.java
public class ISO8601Utils {
	/**
	 * ID to represent the 'UTC' string, default timezone since Jackson 2.7
	 * 
	 * @since 2.7
	 */
	private static final String UTC_ID = "UTC";
	/**
	 * The UTC timezone, prefetched to avoid more lookups.
	 * 
	 * @since 2.7
	 */
	private static final TimeZone TIMEZONE_UTC = TimeZone.getTimeZone(UTC_ID);

	/*
	 * /********************************************************** /* Formatting
	 * /**********************************************************
	 */

	/**
	 * Format a date into 'yyyy-MM-ddThh:mm:ssZ' (default timezone, no milliseconds
	 * precision)
	 * 
	 * @param date
	 *            the date to format
	 * @return the date formatted as 'yyyy-MM-ddThh:mm:ssZ'
	 */
	public static String format(Date date) {
		return format(date, false, TIMEZONE_UTC);
	}

	/**
	 * Format a date into 'yyyy-MM-ddThh:mm:ss[.sss]Z' (GMT timezone)
	 * 
	 * @param date
	 *            the date to format
	 * @param millis
	 *            true to include millis precision otherwise false
	 * @return the date formatted as 'yyyy-MM-ddThh:mm:ss[.sss]Z'
	 */
	public static String format(Date date, boolean millis) {
		return format(date, millis, TIMEZONE_UTC);
	}

	/**
	 * Format date into yyyy-MM-ddThh:mm:ss[.sss][Z|[+-]hh:mm]
	 * 
	 * @param date
	 *            the date to format
	 * @param millis
	 *            true to include millis precision otherwise false
	 * @param tz
	 *            timezone to use for the formatting (UTC will produce 'Z')
	 * @return the date formatted as yyyy-MM-ddThh:mm:ss[.sss][Z|[+-]hh:mm]
	 */
	public static String format(Date date, boolean millis, TimeZone tz) {
		Calendar calendar = new GregorianCalendar(tz, Locale.US);
		calendar.setTime(date);

		// estimate capacity of buffer as close as we can (yeah, that's pedantic ;)
		int capacity = "yyyy-MM-ddThh:mm:ss".length();
		capacity += millis ? ".sss".length() : 0;
		capacity += tz.getRawOffset() == 0 ? "Z".length() : "+hh:mm".length();
		StringBuilder formatted = new StringBuilder(capacity);

		padInt(formatted, calendar.get(Calendar.YEAR), "yyyy".length());
		formatted.append('-');
		padInt(formatted, calendar.get(Calendar.MONTH) + 1, "MM".length());
		formatted.append('-');
		padInt(formatted, calendar.get(Calendar.DAY_OF_MONTH), "dd".length());
		formatted.append('T');
		padInt(formatted, calendar.get(Calendar.HOUR_OF_DAY), "hh".length());
		formatted.append(':');
		padInt(formatted, calendar.get(Calendar.MINUTE), "mm".length());
		formatted.append(':');
		padInt(formatted, calendar.get(Calendar.SECOND), "ss".length());
		if (millis) {
			formatted.append('.');
			padInt(formatted, calendar.get(Calendar.MILLISECOND), "sss".length());
		}

		int offset = tz.getOffset(calendar.getTimeInMillis());
		if (offset != 0) {
			int hours = Math.abs((offset / (60 * 1000)) / 60);
			int minutes = Math.abs((offset / (60 * 1000)) % 60);
			formatted.append(offset < 0 ? '-' : '+');
			padInt(formatted, hours, "hh".length());
			formatted.append(':');
			padInt(formatted, minutes, "mm".length());
		} else {
			formatted.append('Z');
		}

		return formatted.toString();
	}

	/*
	 * /********************************************************** /* Parsing
	 * /**********************************************************
	 */

	/**
	 * Parse a date from ISO-8601 formatted string. It expects a format
	 * [yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:mm]]]
	 * 
	 * @param date
	 *            ISO string to parse in the appropriate format.
	 * @param pos
	 *            The position to start parsing from, updated to where parsing
	 *            stopped.
	 * @return the parsed date
	 * @throws ParseException
	 *             if the date is not in the appropriate format
	 */
	public static Date parse(String date, ParsePosition pos) throws ParseException {
		Exception fail = null;
		try {
			int offset = pos.getIndex();

			// extract year
			int year = parseInt(date, offset, offset += 4);
			if (checkOffset(date, offset, '-')) {
				offset += 1;
			}

			// extract month
			int month = parseInt(date, offset, offset += 2);
			if (checkOffset(date, offset, '-')) {
				offset += 1;
			}

			// extract day
			int day = parseInt(date, offset, offset += 2);
			// default time value
			int hour = 0;
			int minutes = 0;
			int seconds = 0;
			int milliseconds = 0; // always use 0 otherwise returned date will include millis of current time

			// if the value has no time component (and no time zone), we are done
			boolean hasT = checkOffset(date, offset, 'T');

			if (!hasT && (date.length() <= offset)) {
				Calendar calendar = new GregorianCalendar(year, month - 1, day);

				pos.setIndex(offset);
				return calendar.getTime();
			}

			if (hasT) {

				// extract hours, minutes, seconds and milliseconds
				hour = parseInt(date, offset += 1, offset += 2);
				if (checkOffset(date, offset, ':')) {
					offset += 1;
				}

				minutes = parseInt(date, offset, offset += 2);
				if (checkOffset(date, offset, ':')) {
					offset += 1;
				}
				// second and milliseconds can be optional
				if (date.length() > offset) {
					char c = date.charAt(offset);
					if (c != 'Z' && c != '+' && c != '-') {
						seconds = parseInt(date, offset, offset += 2);
						if (seconds > 59 && seconds < 63)
							seconds = 59; // truncate up to 3 leap seconds
						// milliseconds can be optional in the format
						if (checkOffset(date, offset, '.')) {
							offset += 1;
							int endOffset = indexOfNonDigit(date, offset + 1); // assume at least one digit
							int parseEndOffset = Math.min(endOffset, offset + 3); // parse up to 3 digits
							int fraction = parseInt(date, offset, parseEndOffset);
							// compensate for "missing" digits
							switch (parseEndOffset - offset) { // number of digits parsed
							case 2:
								milliseconds = fraction * 10;
								break;
							case 1:
								milliseconds = fraction * 100;
								break;
							default:
								milliseconds = fraction;
							}
							offset = endOffset;
						}
					}
				}
			}

			// extract timezone
			if (date.length() <= offset) {
				throw new IllegalArgumentException("No time zone indicator");
			}

			TimeZone timezone = null;
			char timezoneIndicator = date.charAt(offset);

			if (timezoneIndicator == 'Z') {
				timezone = TIMEZONE_UTC;
				offset += 1;
			} else if (timezoneIndicator == '+' || timezoneIndicator == '-') {
				String timezoneOffset = date.substring(offset);

				// When timezone has no minutes, we should append it, valid timezones are, for
				// example: +00:00, +0000 and +00
				timezoneOffset = timezoneOffset.length() >= 5 ? timezoneOffset : timezoneOffset + "00";

				offset += timezoneOffset.length();
				// 18-Jun-2015, tatu: Minor simplification, skip offset of "+0000"/"+00:00"
				if ("+0000".equals(timezoneOffset) || "+00:00".equals(timezoneOffset)) {
					timezone = TIMEZONE_UTC;
				} else {
					// 18-Jun-2015, tatu: Looks like offsets only work from GMT, not UTC...
					// not sure why, but that's the way it looks. Further, Javadocs for
					// `java.util.TimeZone` specifically instruct use of GMT as base for
					// custom timezones... odd.
					String timezoneId = "GMT" + timezoneOffset;
					// String timezoneId = "UTC" + timezoneOffset;

					timezone = TimeZone.getTimeZone(timezoneId);

					String act = timezone.getID();
					if (!act.equals(timezoneId)) {
						/*
						 * 22-Jan-2015, tatu: Looks like canonical version has colons, but we may be
						 * given one without. If so, don't sweat. Yes, very inefficient. Hopefully not
						 * hit often. If it becomes a perf problem, add 'loose' comparison instead.
						 */
						String cleaned = act.replace(":", "");
						if (!cleaned.equals(timezoneId)) {
							throw new IndexOutOfBoundsException("Mismatching time zone indicator: " + timezoneId + " given, resolves to " + timezone.getID());
						}
					}
				}
			} else {
				throw new IndexOutOfBoundsException("Invalid time zone indicator '" + timezoneIndicator + "'");
			}

			Calendar calendar = new GregorianCalendar(timezone);
			calendar.setLenient(false);
			calendar.set(Calendar.YEAR, year);
			calendar.set(Calendar.MONTH, month - 1);
			calendar.set(Calendar.DAY_OF_MONTH, day);
			calendar.set(Calendar.HOUR_OF_DAY, hour);
			calendar.set(Calendar.MINUTE, minutes);
			calendar.set(Calendar.SECOND, seconds);
			calendar.set(Calendar.MILLISECOND, milliseconds);

			pos.setIndex(offset);
			return calendar.getTime();
			// If we get a ParseException it'll already have the right message/offset.
			// Other exception types can convert here.
		} catch (IndexOutOfBoundsException e) {
			fail = e;
		} catch (NumberFormatException e) {
			fail = e;
		} catch (IllegalArgumentException e) {
			fail = e;
		}
		String input = (date == null) ? null : ('"' + date + "'");
		String msg = fail.getMessage();
		if (msg == null || msg.isEmpty()) {
			msg = "(" + fail.getClass().getName() + ")";
		}
		ParseException ex = new ParseException("Failed to parse date [" + input + "]: " + msg, pos.getIndex());
		ex.initCause(fail);
		throw ex;
	}

	/**
	 * Check if the expected character exist at the given offset in the value.
	 * 
	 * @param value
	 *            the string to check at the specified offset
	 * @param offset
	 *            the offset to look for the expected character
	 * @param expected
	 *            the expected character
	 * @return true if the expected character exist at the given offset
	 */
	private static boolean checkOffset(String value, int offset, char expected) {
		return (offset < value.length()) && (value.charAt(offset) == expected);
	}

	/**
	 * Parse an integer located between 2 given offsets in a string
	 * 
	 * @param value
	 *            the string to parse
	 * @param beginIndex
	 *            the start index for the integer in the string
	 * @param endIndex
	 *            the end index for the integer in the string
	 * @return the int
	 * @throws NumberFormatException
	 *             if the value is not a number
	 */
	private static int parseInt(String value, int beginIndex, int endIndex) throws NumberFormatException {
		if (beginIndex < 0 || endIndex > value.length() || beginIndex > endIndex) {
			throw new NumberFormatException(value);
		}
		// use same logic as in Integer.parseInt() but less generic we're not supporting
		// negative values
		int i = beginIndex;
		int result = 0;
		int digit;
		if (i < endIndex) {
			digit = Character.digit(value.charAt(i++), 10);
			if (digit < 0) {
				throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex));
			}
			result = -digit;
		}
		while (i < endIndex) {
			digit = Character.digit(value.charAt(i++), 10);
			if (digit < 0) {
				throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex));
			}
			result *= 10;
			result -= digit;
		}
		return -result;
	}

	/**
	 * Zero pad a number to a specified length
	 * 
	 * @param buffer
	 *            buffer to use for padding
	 * @param value
	 *            the integer value to pad if necessary.
	 * @param length
	 *            the length of the string we should zero pad
	 */
	private static void padInt(StringBuilder buffer, int value, int length) {
		String strValue = Integer.toString(value);
		for (int i = length - strValue.length(); i > 0; i--) {
			buffer.append('0');
		}
		buffer.append(strValue);
	}

	/**
	 * Returns the index of the first character in the string that is not a digit,
	 * starting at offset.
	 */
	private static int indexOfNonDigit(String string, int offset) {
		for (int i = offset; i < string.length(); i++) {
			char c = string.charAt(i);
			if (c < '0' || c > '9')
				return i;
		}
		return string.length();
	}

}
